summaryrefslogtreecommitdiff
path: root/app/[lng]/evcp/data-room/[projectId]/members/page.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-10-15 12:52:11 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-10-15 12:52:11 +0000
commitb54f6f03150dd78d86db62201b6386bf14b72394 (patch)
treeb3092bb34805fdc65eee5282e86a9fb90ba20d6e /app/[lng]/evcp/data-room/[projectId]/members/page.tsx
parentc1bd1a2f499ee2f0742170021b37dab410983ab7 (diff)
(대표님) 커버, 데이터룸, 파일매니저, 담당자할당 등
Diffstat (limited to 'app/[lng]/evcp/data-room/[projectId]/members/page.tsx')
-rw-r--r--app/[lng]/evcp/data-room/[projectId]/members/page.tsx811
1 files changed, 811 insertions, 0 deletions
diff --git a/app/[lng]/evcp/data-room/[projectId]/members/page.tsx b/app/[lng]/evcp/data-room/[projectId]/members/page.tsx
new file mode 100644
index 00000000..18442c0e
--- /dev/null
+++ b/app/[lng]/evcp/data-room/[projectId]/members/page.tsx
@@ -0,0 +1,811 @@
+// app/projects/[projectId]/members/page.tsx
+'use client';
+
+import { use, useState, useEffect, useRef } from 'react';
+import {
+ Users,
+ UserPlus,
+ Crown,
+ Shield,
+ Eye,
+ Edit2,
+ Trash2,
+ Mail,
+ MoreVertical,
+ Search,
+ Filter,
+ Check,
+ ChevronsUpDown,
+ Loader2,
+ UserCog
+} from 'lucide-react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Badge } from '@/components/ui/badge';
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command';
+import { Label } from '@/components/ui/label';
+import { useToast } from '@/hooks/use-toast';
+import { cn } from '@/lib/utils';
+import {
+ Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow
+} from '@/components/ui/table';
+import { Separator } from '@/components/ui/separator';
+import { getUsersForFilter } from '@/lib/gtc-contract/service';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+
+interface Member {
+ id: string;
+ userId: number;
+ user: {
+ name: string;
+ email: string;
+ imageUrl?: string;
+ domain: string;
+ };
+ role: 'owner' | 'admin' | 'editor' | 'viewer';
+ addedAt: string;
+ lastAccess?: string;
+}
+
+interface User {
+ id: number;
+ name: string;
+ email: string;
+ domain?: string; // 'partners' | 'internal' 등
+}
+
+export default function ProjectMembersPage({
+ params: promiseParams
+}: {
+ params: Promise<{ projectId: string }>
+}) {
+ // Next.js 15+ params Promise 처리
+ const params = use(promiseParams);
+ const projectId = params.projectId;
+
+ const [members, setMembers] = useState<Member[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [roleFilter, setRoleFilter] = useState<string>('all');
+ const [addMemberOpen, setAddMemberOpen] = useState(false);
+ const [editingMember, setEditingMember] = useState<Member | null>(null);
+
+ // 사용자 선택 관련 상태
+ const [availableUsers, setAvailableUsers] = useState<User[]>([]);
+ const [selectedUser, setSelectedUser] = useState<User | null>(null);
+ const [userSearchTerm, setUserSearchTerm] = useState('');
+ const [userPopoverOpen, setUserPopoverOpen] = useState(false);
+ const [loadingUsers, setLoadingUsers] = useState(false);
+ const [isExternalUser, setIsExternalUser] = useState(false); // 외부 사용자 여부
+
+ const [newMemberRole, setNewMemberRole] = useState<string>('viewer');
+ const [currentUserRole, setCurrentUserRole] = useState<string>('viewer');
+ const [page, setPage] = useState(1);
+ const pageSize = 20;
+
+ // Command component key management
+ const userOptionIdsRef = useRef<Record<number, string>>({});
+ const popoverContentId = `popover-content-${Date.now()}`;
+ const commandId = `command-${Date.now()}`;
+
+ const { toast } = useToast();
+
+ useEffect(() => {
+ setPage(1);
+ }, [searchQuery, roleFilter]);
+
+ useEffect(() => {
+ fetchMembers();
+ checkUserRole();
+ }, [projectId]);
+
+ // 다이얼로그가 열릴 때 사용자 목록 가져오기
+ useEffect(() => {
+ if (addMemberOpen) {
+ fetchAvailableUsers();
+ } else {
+ // 다이얼로그가 닫힐 때 초기화
+ setSelectedUser(null);
+ setUserSearchTerm('');
+ setNewMemberRole('viewer');
+ setIsExternalUser(false);
+ }
+ }, [addMemberOpen]);
+
+ const fetchAvailableUsers = async () => {
+ try {
+ setLoadingUsers(true);
+ const users = await getUsersForFilter();
+ // 이미 프로젝트에 있는 멤버는 제외
+ const memberUserIds = members.map(m => m.userId);
+ const filteredUsers = users.filter(u => !memberUserIds.includes(u.id));
+ setAvailableUsers(filteredUsers);
+ } catch (error) {
+ console.error('사용자 목록 로드 실패:', error);
+ toast({
+ title: '오류',
+ description: '사용자 목록을 불러올 수 없습니다.',
+ variant: 'destructive',
+ });
+ } finally {
+ setLoadingUsers(false);
+ }
+ };
+
+ const fetchMembers = async () => {
+ try {
+ setLoading(true);
+ const response = await fetch(`/api/projects/${projectId}/members`);
+ const data = await response.json();
+ setMembers(data.member);
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '멤버 목록을 불러올 수 없습니다.',
+ variant: 'destructive',
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const checkUserRole = async () => {
+ try {
+ const response = await fetch(`/api/projects/${projectId}/access`);
+ const data = await response.json();
+ setCurrentUserRole(data.role);
+ } catch (error) {
+ console.error('권한 확인 실패:', error);
+ }
+ };
+
+ const addMember = async () => {
+ if (!selectedUser) {
+ toast({
+ title: '오류',
+ description: '사용자를 선택해주세요.',
+ variant: 'destructive',
+ });
+ return;
+ }
+
+ try {
+ const response = await fetch(`/api/projects/${projectId}/members`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ userId: selectedUser.id,
+ role: newMemberRole,
+ }),
+ });
+
+ if (!response.ok) throw new Error('멤버 추가 실패');
+
+ toast({
+ title: '성공',
+ description: '새 멤버가 추가되었습니다.',
+ });
+
+ setAddMemberOpen(false);
+ fetchMembers();
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '멤버 추가에 실패했습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const updateMemberRole = async (memberId: string, newRole: string) => {
+ try {
+ const response = await fetch(`/api/projects/${projectId}/members/${memberId}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ role: newRole }),
+ });
+
+ if (!response.ok) throw new Error('역할 변경 실패');
+
+ toast({
+ title: '성공',
+ description: '멤버 역할이 변경되었습니다.',
+ });
+
+ fetchMembers();
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '역할 변경에 실패했습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const removeMember = async (memberId: string) => {
+ try {
+ const response = await fetch(`/api/projects/${projectId}/members/${memberId}`, {
+ method: 'DELETE',
+ });
+
+ if (!response.ok) throw new Error('멤버 제거 실패');
+
+ toast({
+ title: '성공',
+ description: '멤버가 제거되었습니다.',
+ });
+
+ fetchMembers();
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '멤버 제거에 실패했습니다.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ const handleSelectUser = (user: User) => {
+ setSelectedUser(user);
+ setUserPopoverOpen(false);
+
+ // 외부 사용자(partners)인 경우 역할을 viewer로 고정
+ if (user.domain === 'partners') {
+ setIsExternalUser(true);
+ setNewMemberRole('viewer');
+ } else {
+ setIsExternalUser(false);
+ // 내부 사용자는 기본값 viewer로 설정하되 변경 가능
+ setNewMemberRole('viewer');
+ }
+ };
+
+ const formatDateShort = (iso?: string) =>
+ iso ? new Date(iso).toLocaleDateString() : '-';
+
+ const roleConfig = {
+ owner: { label: 'Owner', icon: Crown, color: 'text-yellow-500', bg: 'bg-yellow-50' },
+ admin: { label: 'Admin', icon: Shield, color: 'text-blue-500', bg: 'bg-blue-50' },
+ editor: { label: 'Editor', icon: Edit2, color: 'text-green-500', bg: 'bg-green-50' },
+ viewer: { label: 'Viewer', icon: Eye, color: 'text-gray-500', bg: 'bg-gray-50' },
+ };
+
+ const filteredMembers = members.filter(member => {
+ const matchesSearch = member.user.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ member.user.email.toLowerCase().includes(searchQuery.toLowerCase());
+ const matchesRole = roleFilter === 'all' || member.role === roleFilter;
+ return matchesSearch && matchesRole;
+ });
+
+ // 사용자 검색 필터링
+ const filteredUsers = availableUsers.filter(user =>
+ user.name.toLowerCase().includes(userSearchTerm.toLowerCase()) ||
+ user.email.toLowerCase().includes(userSearchTerm.toLowerCase())
+ );
+
+ const canManageMembers = currentUserRole === 'owner' || currentUserRole === 'admin';
+
+ const totalPages = Math.max(1, Math.ceil(filteredMembers.length / pageSize));
+ const paginatedMembers = filteredMembers.slice((page - 1) * pageSize, page * pageSize);
+
+ if (loading) {
+ return (
+ <div className="flex items-center justify-center min-h-[400px]">
+ <div className="text-center space-y-3">
+ <Loader2 className="h-8 w-8 animate-spin text-primary mx-auto" />
+ <p className="text-sm text-muted-foreground">멤버 목록을 불러오는 중...</p>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="p-6 space-y-6">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between">
+ <div>
+ <h1 className="text-2xl font-bold">프로젝트 멤버</h1>
+ <p className="text-muted-foreground mt-1">
+ 프로젝트에 참여 중인 멤버를 관리합니다
+ </p>
+ </div>
+
+ {canManageMembers && (
+ <Button onClick={() => setAddMemberOpen(true)}>
+ <UserPlus className="h-4 w-4 mr-2" />
+ 멤버 추가
+ </Button>
+ )}
+ </div>
+
+ {/* 필터 */}
+ <div className="flex items-center gap-3">
+ <div className="relative flex-1 max-w-md">
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="이름 또는 이메일로 검색..."
+ className="pl-9"
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ />
+ </div>
+
+ <Select value={roleFilter} onValueChange={setRoleFilter}>
+ <SelectTrigger className="w-40">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">모든 역할</SelectItem>
+ <SelectItem value="owner">Owner</SelectItem>
+ <SelectItem value="admin">Admin</SelectItem>
+ <SelectItem value="editor">Editor</SelectItem>
+ <SelectItem value="viewer">Viewer</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* 멤버 목록 (Table) */}
+ <div className="overflow-x-auto">
+ <Table className="[&_td]:py-2 [&_th]:py-2 text-sm">
+ <TableHeader className="sticky top-0 bg-background z-10">
+ <TableRow>
+ <TableHead className="w-[44px]"></TableHead>
+ <TableHead className="w-[100px]">이름</TableHead>
+ <TableHead className="min-w-[150px]">이메일</TableHead>
+ <TableHead className="w-[90px] text-center">구분</TableHead>
+ <TableHead className="w-[140px]">역할</TableHead>
+ <TableHead className="w-[130px]">추가일</TableHead>
+ <TableHead className="w-[150px]">마지막 접속</TableHead>
+ <TableHead className="w-[60px] text-right">액션</TableHead>
+ </TableRow>
+ </TableHeader>
+
+ <TableBody>
+ {paginatedMembers.length > 0 ? (
+ paginatedMembers.map((member) => {
+ const config = roleConfig[member.role];
+ const Icon = config.icon;
+ const isInternal = member.user.domain !== 'partners';
+
+ return (
+ <TableRow key={member.id} className="hover:bg-accent/40">
+ {/* Avatar */}
+ <TableCell className="align-middle">
+ <Avatar className="h-8 w-8">
+ <AvatarImage src={member.user.imageUrl} />
+ <AvatarFallback>
+ {member.user.name?.charAt(0).toUpperCase()}
+ </AvatarFallback>
+ </Avatar>
+ </TableCell>
+
+ {/* Name */}
+ <TableCell className="align-middle">
+ <span className="font-medium">{member.user.name}</span>
+ </TableCell>
+
+ {/* Email */}
+ <TableCell className="align-middle">
+ <span className="text-muted-foreground">{member.user.email}</span>
+ </TableCell>
+
+ {/* Domain */}
+ <TableCell className="align-middle text-center">
+ <Badge variant={isInternal ? 'secondary' : 'outline'}>
+ {isInternal ? 'Internal' : 'Partner'}
+ </Badge>
+ </TableCell>
+
+ {/* Role */}
+ <TableCell className="align-middle">
+ {canManageMembers && member.role !== 'owner' && member.user.domain !== 'partners' ? (
+ <Select
+ value={member.role}
+ onValueChange={(v) => updateMemberRole(member.id, v)}
+ >
+ <SelectTrigger className="h-8 w-[120px]">
+ <div className={cn('flex items-center gap-1')}>
+ <Icon className={cn('h-3 w-3', config.color)} />
+ <span className={cn('text-xs font-medium')}>
+ {config.label}
+ </span>
+ </div>
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="viewer">Viewer</SelectItem>
+ <SelectItem value="editor">Editor</SelectItem>
+ <SelectItem value="admin">Admin</SelectItem>
+ </SelectContent>
+ </Select>
+ ) : (
+ <div className="inline-flex items-center gap-2">
+ <div className={cn('px-2 py-1 rounded-full inline-flex items-center gap-1', config.bg)}>
+ <Icon className={cn('h-3 w-3', config.color)} />
+ <span className={cn('text-xs font-medium', config.color)}>
+ {config.label}
+ </span>
+ </div>
+ {member.user.domain === 'partners' && canManageMembers && member.role !== 'owner' && (
+ <span className="text-xs text-muted-foreground">(고정)</span>
+ )}
+ </div>
+ )}
+ </TableCell>
+
+ {/* AddedAt */}
+ <TableCell className="align-middle">
+ {formatDateShort(member.addedAt)}
+ </TableCell>
+
+ {/* LastAccess */}
+ <TableCell className="align-middle">
+ {formatDateShort(member.lastAccess)}
+ </TableCell>
+
+ {/* Actions */}
+ <TableCell className="align-middle">
+ <div className="flex justify-end">
+ {canManageMembers && member.role !== 'owner' ? (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" size="icon">
+ <MoreVertical className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem>
+ <Mail className="h-4 w-4 mr-2" />
+ 메일 보내기
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ className="text-red-600"
+ onClick={() => removeMember(member.id)}
+ >
+ <Trash2 className="h-4 w-4 mr-2" />
+ 제거
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ ) : (
+ <Button variant="ghost" size="icon" disabled>
+ <MoreVertical className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </TableCell>
+ </TableRow>
+ );
+ })
+ ) : (
+ <TableRow>
+ <TableCell colSpan={8} className="h-32 text-center text-muted-foreground">
+ <div className="flex flex-col items-center justify-center gap-2">
+ <Users className="h-8 w-8 text-muted-foreground/60" />
+ <span>검색 결과가 없습니다</span>
+ </div>
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+
+ {/* Pagination */}
+ <div className="flex items-center justify-between px-4 py-3 border-t">
+ <div className="text-sm text-muted-foreground">
+ 총 {filteredMembers.length}명 · {pageSize}명/페이지
+ </div>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
+ disabled={page === 1}
+ >
+ 이전
+ </Button>
+ <span className="text-sm">
+ {page} / {totalPages}
+ </span>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
+ disabled={page === totalPages}
+ >
+ 다음
+ </Button>
+ </div>
+ </div>
+
+ {/* 멤버 추가 다이얼로그 */}
+ <Dialog open={addMemberOpen} onOpenChange={setAddMemberOpen}>
+ <DialogContent className="max-w-lg">
+ <DialogHeader>
+ <DialogTitle>멤버 추가</DialogTitle>
+ <DialogDescription>
+ 프로젝트에 멤버를 추가합니다
+ </DialogDescription>
+ </DialogHeader>
+
+ <Tabs defaultValue="internal" className="w-full">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="internal">내부 사용자</TabsTrigger>
+ <TabsTrigger value="external" className="flex items-center gap-2">
+ 외부 사용자
+ <Badge variant="outline" className="ml-1 text-xs">Viewer 전용</Badge>
+ </TabsTrigger>
+ </TabsList>
+
+ <TabsContent value="internal" className="space-y-4 mt-4">
+ <div className="space-y-2">
+ <Label htmlFor="internal-user">사용자 선택</Label>
+
+ {loadingUsers ? (
+ <div className="flex items-center justify-center py-4">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ <span className="ml-2 text-sm text-muted-foreground">사용자 목록 불러오는 중...</span>
+ </div>
+ ) : (
+ <>
+ <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={userPopoverOpen}
+ className="w-full justify-between"
+ >
+ <span className="truncate">
+ {selectedUser && selectedUser.domain !== 'partners' ? (
+ <div className="text-left">
+ <div className="font-medium">{selectedUser.name}</div>
+ <div className="text-xs text-muted-foreground">{selectedUser.email}</div>
+ </div>
+ ) : (
+ "내부 사용자를 선택하세요..."
+ )}
+ </span>
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[460px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="이름 또는 이메일로 검색..."
+ value={userSearchTerm}
+ onValueChange={setUserSearchTerm}
+ />
+ <CommandList
+ className="max-h-[300px]"
+ onWheel={(e) => {
+ e.stopPropagation();
+ const target = e.currentTarget;
+ target.scrollTop += e.deltaY;
+ }}
+ >
+ <CommandEmpty>사용자를 찾을 수 없습니다.</CommandEmpty>
+ <CommandGroup heading="내부 사용자 목록">
+ {filteredUsers
+ .filter(u => u.domain !== 'partners')
+ .map((user) => (
+ <CommandItem
+ key={user.id}
+ onSelect={() => {
+ setSelectedUser(user);
+ setUserPopoverOpen(false);
+ setIsExternalUser(false);
+ setNewMemberRole('viewer');
+ }}
+ value={`${user.name} ${user.email}`}
+ className="truncate"
+ >
+ <Users className="mr-2 h-4 w-4 text-blue-500 flex-shrink-0" />
+ <div className="flex-1 truncate">
+ <div className="font-medium truncate">{user.name}</div>
+ <div className="text-xs text-muted-foreground truncate">{user.email}</div>
+ </div>
+ <Check
+ className={cn(
+ "ml-2 h-4 w-4 flex-shrink-0",
+ selectedUser?.id === user.id && !isExternalUser ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+
+ <p className="text-xs text-muted-foreground">
+ 내부 사용자는 모든 역할을 부여할 수 있습니다.
+ </p>
+ </>
+ )}
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="internal-role">역할</Label>
+ <Select
+ value={newMemberRole}
+ onValueChange={setNewMemberRole}
+ disabled={!selectedUser || isExternalUser}
+ >
+ <SelectTrigger id="internal-role">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="viewer">Viewer - 읽기 전용</SelectItem>
+ <SelectItem value="editor">Editor - 파일 편집 가능</SelectItem>
+ <SelectItem value="admin">Admin - 프로젝트 관리</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </TabsContent>
+
+ <TabsContent value="external" className="space-y-4 mt-4">
+ <div className="rounded-lg bg-amber-50 border border-amber-200 p-3 mb-4">
+ <p className="text-sm text-amber-800">
+ <strong>보안 정책 안내</strong><br/>
+ 외부 사용자(파트너)는 보안 정책상 Viewer 권한만 부여 가능합니다.
+ </p>
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="external-user">파트너 선택</Label>
+
+ {loadingUsers ? (
+ <div className="flex items-center justify-center py-4">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ <span className="ml-2 text-sm text-muted-foreground">사용자 목록 불러오는 중...</span>
+ </div>
+ ) : (
+ <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={userPopoverOpen}
+ className="w-full justify-between"
+ >
+ <span className="truncate">
+ {selectedUser && selectedUser.domain === 'partners' ? (
+ <span className="flex items-center gap-2">
+ {selectedUser.name}
+ <Badge variant="outline" className="ml-1 text-xs">외부</Badge>
+ </span>
+ ) : (
+ "외부 사용자를 선택하세요..."
+ )}
+ </span>
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[460px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="이름으로 검색..."
+ value={userSearchTerm}
+ onValueChange={setUserSearchTerm}
+ />
+ <CommandList
+ className="max-h-[300px]"
+ onWheel={(e) => {
+ e.stopPropagation();
+ const target = e.currentTarget;
+ target.scrollTop += e.deltaY;
+ }}
+ >
+ <CommandEmpty>파트너를 찾을 수 없습니다.</CommandEmpty>
+ <CommandGroup heading="파트너 목록">
+ {filteredUsers
+ .filter(u => u.domain === 'partners')
+ .map((user) => (
+ <CommandItem
+ key={user.id}
+ onSelect={() => {
+ setSelectedUser(user);
+ setUserPopoverOpen(false);
+ setIsExternalUser(true);
+ setNewMemberRole('viewer');
+ }}
+ value={user.name}
+ className="truncate"
+ >
+ <Users className="mr-2 h-4 w-4 text-amber-600" />
+ <span className="truncate flex-1">{user.name}</span>
+ <Badge variant="outline" className="text-xs mx-2">파트너</Badge>
+ <Check
+ className={cn(
+ "ml-auto h-4 w-4 flex-shrink-0",
+ selectedUser?.id === user.id && isExternalUser ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ )}
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="external-role">역할</Label>
+ <Select value="viewer" disabled>
+ <SelectTrigger id="external-role" className="opacity-60">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="viewer">Viewer - 읽기 전용 (고정)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </TabsContent>
+ </Tabs>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setAddMemberOpen(false);
+ setSelectedUser(null);
+ setUserSearchTerm('');
+ setNewMemberRole('viewer');
+ setIsExternalUser(false);
+ }}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={addMember}
+ disabled={!selectedUser}
+ >
+ 추가하기
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+} \ No newline at end of file